黑马点评 (高并发场景实战)
这是我在简历中针对社交电商高并发核心交易的实战项目(2026.04 - 2026.05)。
涉及的核心底层八股知识
- [[多级缓存与一致性]]
- [[分布式锁]]
- [[Redis 与缓存]]
- [[Spring 与 AOP]]
- [[MySQL 与数据库]]
核心亮点场景复盘
1. 多级缓存抗压 (Caffeine + Redis 防穿透与击穿)
简历原句与锚点
针对热点商铺查询,构建 Caffeine + Redis 二级缓存架构;通过 “空值缓存 + 逻辑过期” 策略有效规避缓存穿透与击穿;压测下核心接口响应延迟显著降低。
面试官追问路径
- 怎么实现逻辑过期?互斥锁是怎么加的?
- 重建缓存时,如果拿不到锁,程序会阻塞等待吗?(答案:不会,直接返回旧数据,避免线程阻塞导致堆积)。
- 怎么防止缓存穿透?如果数据库没有这个值,Redis 存了什么?(答案:存
"NULL",设置短 TTL)。
核心骨架代码
java
// CacheClient.java - 逻辑过期解决缓存击穿骨架
public <T, ID> T getWithLogicalExpire(String keyPrefix, ID id, Class<T> clazz,
Long expireTime, TimeUnit unit, String lockKey, Function<ID, T> dbFallback) {
String key = keyPrefix + id;
String jsonBean = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(jsonBean)) return null; // 未命中直接返回null(依赖缓存预热)
RedisData redisData = JsonUtils.toBean(jsonBean, RedisData.class);
LocalDateTime time = redisData.getExpireTime();
// 1. 判断是否逻辑过期
if (LocalDateTime.now().isBefore(time)) {
return JsonUtils.convert(redisData.getData(), clazz); // 未过期,直接返回旧数据
}
// 2. 已过期,尝试抢互斥锁重建缓存
if (lock(lockKey)) {
// 抢锁成功,开启独立线程池异步重建,不阻塞主流程
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// Double-check 检查是否已被其他线程重建
String latestJson = stringRedisTemplate.opsForValue().get(key);
RedisData latestData = JsonUtils.toBean(latestJson, RedisData.class);
if (latestData == null || LocalDateTime.now().isBefore(latestData.getExpireTime())) {
return;
}
T res = dbFallback.apply(id); // 查数据库
if (res == null) {
stringRedisTemplate.delete(key);
} else {
setWithLogicalExpire(res, key, expireTime, unit); // 重写逻辑过期数据
}
} finally {
unlock(lockKey); // 释放锁
}
});
}
// 3. 抢锁失败或抢锁成功但异步重建未完成,均直接返回旧数据
return JsonUtils.convert(redisData.getData(), clazz);
}踩坑与防御性设计
- 缓存穿透短路防御:数据库不存在的值(如恶意爬虫查询不存在的商铺 ID),通过
getBeanWithCachePenetration写入缓存值"NULL",设置 2分钟短 TTL。在下次查询时命中"NULL"判定,直接返回null截断,阻断高流量直接打入数据库。 - 线程死锁与重构堵塞:高并发下大量线程因为等待缓存重建而阻塞,极易导致 Tomcat 线程池耗尽。防御性设计为:逻辑过期判定 + 异步重构 + 失败立返旧数据。抢互斥锁失败的线程无需等待,立即拿旧数据妥协返回,系统吞吐量呈数量级提升。
2. 高并发秒杀优化 (Redis + Lua & Stream 队列异步下单)
简历原句与锚点
利用 Redis + Lua 脚本实现库存预扣减与用户一人一单的原子化校验,配合 Redisson 分布式锁兜底,彻底解决集群环境下的超卖问题。
面试官追问路径
- 为什么要把秒杀校验放到 Lua 脚本里?
- 你的 Redis Stream 是怎么保证消息不丢失的?如果处理订单的异步线程在挂起前报错了,数据怎么办?
核心骨架代码
lua
-- seckill.lua 校验与预扣减脚本
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order' .. voucherId
-- 1. 校验库存
local stock = redis.call('get', stockKey)
if tonumber(stock) <= 0 then
return 1 -- 库存不足
end
-- 2. 校验一人一单
local ordered = redis.call('SISMEMBER', orderKey, userId)
if ordered == 1 then
return 2 -- 重复下单
end
-- 3. 扣减库存并标记已下过单
redis.call('DECR', stockKey)
redis.call('SADD', orderKey, userId)
-- 4. 塞入消息队列异步消费
redis.call('XADD', 'stream.orders', '*', 'voucherId', voucherId, 'userId', userId, 'id', orderId)
return 0java
// VoucherOrderServiceImpl.java 异步队列消费骨架
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1. 从消费者组消费 stream 消息 (包含ACK)
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
if (list == null || list.isEmpty()) continue;
MapRecord<String, Object, Object> record = list.get(0);
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(record.getValue(), new VoucherOrder(), true);
handleVoucherOrder(voucherOrder); // 扣库存下单
// 2. 消费成功,手动发送 ACK
stringRedisTemplate.opsForStream().acknowledge("stream.orders", "g1", record.getId());
} catch (Exception e) {
// 3. 报错进入 pending-list 异常恢复流
handlePendingList();
}
}
}
}踩坑与防御性设计
- 消息积压与丢失防范:利用 Redis Stream 的 Consumer Group(消费者组) 机制。读取时自动将未 ACK 的消息放入
Pending-List。如果异步线程消费时突发宕机,重启后handlePendingList()会以ReadOffset.from("0")优先级优先消费处于 Pending 状态的滞留消息并重试 ACK,保证秒杀订单 100% 被入库处理。
3. AOP 代理自调用失效问题 (AopContext.currentProxy)
简历原句与锚点
配合 Redisson 分布式锁兜底,解决集群环境下的超卖问题。(涉及核心 AOP 事务自调用失效防范)
面试官追问路径
- 为什么在异步线程
handleVoucherOrder中需要通过proxy.createVoucherOrder调用,而不是直接用this? - 怎么解决 Spring AOP 的代理自调用失效?
核心骨架代码
java
// VoucherOrderServiceImpl.seckillVoucher 主线程中获取代理对象
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 执行 Lua 脚本预检成功...
// 2. 获取当前类的代理对象并保存到成员变量中,供子线程使用
// 必须在主线程中获取,因为 AopContext 的 ThreadLocal 无法自动透传到子线程中
proxy = (IVoucherOrderService) AopContext.currentProxy();
return Result.ok(orderId);
}
// 异步消费线程执行下单
private void handleVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
RLock lock = redissonClient.getLock("lock:order:" + userId);
if (!lock.tryLock()) return;
try {
// 使用获取到的代理对象去调用事务方法,使 @Transactional 生效
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}踩坑与防御性设计
- 声明式事务与锁冲突:若在
createVoucherOrder方法上标注了@Transactional,而直接在类内部调用this.createVoucherOrder,会导致 AOP 拦截被绕过,事务失效。 - 解决:在主线程执行中,通过
(IVoucherOrderService) AopContext.currentProxy()提前提取出 AOP 代理实例赋给成员变量,并在子线程通过该代理调用。同时,为了保证“在事务提交之后再释放锁”(避免锁释放了但事务还没提交导致高并发读到未提交数据),将分布式锁包裹在代理对象调用的最外层,而非写在事务方法内部。
相关链接:[[黑马点评]]